#!/bin/bash
#
#	Eloy backup system.
#
#	Copyright (c) 2007-2008 Maximilian Antoni. All rights reserved.
#
#	This software is licensed as described in the file LICENSE.txt, which you
#	should have received as part of this distribution. The terms are also
#	available at http://svn.evaserver.com/eloy/trunk/LICENSE.txt.
#

# the Eloy version number:
VERSION="0.3"

# exit values:
E_NO_ARGS=65
E_NOFILE=66

LINE="----------------------------------------------------------------------------"
# the Eloy config file name:
CONFIG_FILE="eloy.cf"

# the default number of backups for the list subcommand:
DEFAULT_LIST_COUNT=5

# prints the version number.
version() {
	echo "Eloy backup system, version $VERSION."
}

# prints general usage.
usage() {
	version
	NAME=`basename $0`
	echo "Usage: $NAME <subcommand> [path/to/eloy.cf]
Type '$NAME help <subcommand>' for help on a specific subcommand.

Available subcommands:
   backup
   compress
   diff (d)
   help (?, h)
   info (i)
   list (l)
   restore (r)

Project homepage: http://evaserver.com/projects/eloy/
"
}

# prints usage for the "info" subcommand.
usageInfo() {
	version
	echo "info (i): print information about the Eloy setup.
usage: info [path/to/eloy.cf]
"
}

# prints usage for the "backup" subcommand.
usageBackup() {
	version
	echo "backup: create a new backup.
usage: backup [path/to/eloy.cf]

   Creates an initial backup, if no current backup is available.
   Otherwise a partial backup is created automatically.

   It is recommended to setup a cron job to create at least one
   backup per day. If you would like to backup your system every
   hour, you should use the compress subcommand to reduce the
   hourly backups after 24 hours.

Valid options:
   -l 	         : write output to logfile
"
}

# prints usage for the "list" subcommand.
usageList() {
	version
	echo "list (l): list backups.
usage: list [path/to/eloy.cf]

   Lists all files that have changed in the backup history.

Valid options:
   -c count      : list <count> backups (defaults to $DEFAULT_LIST_COUNT)
   -f filename   : filter by <filename>
"
}

# prints usage for the "restore" subcommand.
usageRestore() {
	version
	echo "restore (r): restore a file from the backup.
usage: restore <filename> [path/to/eloy.cf]

   Restores the file with <filename> in the current directory with
   the last change that is available in the backup.
   Use the diff command to see the changes made between the current
   version and the backup.

Valid options:
   -b backup     : restore the file from this backup
"
}

# prints usage for the "diff" subcommand.
usageDiff() {
	version
	echo "diff (d): compare a file with a backup.
usage: diff <filename> [path/to/eloy.cf]

   Compares the file with <filename> in the current directory with
   the last change that is available in the backup.

Valid options:
   -b backup     : compare the file with this backup
"
}

# prints usage for the "compress" subcommand.
usageCompress() {
	version
	echo "compress: delete older backups.
usage: compress [path/to/eloy.cf]

   Deletes all but one backup of each day. If COMPRESS_KEEP_BACKUPS
   is not zero, Eloy also deletes old backups if there are more
   than COMPRESS_KEEP_BACKUPS backups, todays backups not included.

   It is recommended to use this command in a cron job which is
   executed every day in the hour before midnight. That way, all
   backups of at least the last 24 hours are kept and older backups
   are reduced to one per day.

Valid options:
   -l 	         : write output to logfile
   -m recipient  : email logfile to recipient and archive logfile
"
}

out() {
	if [ "$WRITE_LOG" == "yes" ]; then
		if [ "$1" != "" ]; then
			echo "[$(date +%Y-%m-%d\ %H:%M:%S)] $1" >> "$LOG_FILENAME"
		fi
	else
		echo $1
	fi
}

# prints an error message stating that a property is missing in the
# configuration and exits.
missingConfigVariable() {
	echo "Missing $1 in \"$CONFIG_FILE\"."
	echo
	exit 1
}

# prints an error message station that a directory does not exist
# and exits.
directoryDoesNotExist() {
	echo "Directory \"$1\" does not exist."
	echo
	exit 1
}

# searches for the configuration file.
findConfigFile() {
	if [ -z $1 ]; then
		CONFIG_FILE="$HOME/.eloy"
		if [ ! -f "$CONFIG_FILE" ]; then
			THIS_DIR=`dirname $0`
			CONFIG_FILE="$THIS_DIR/eloy.cf"
			if [ ! -f "$CONFIG_FILE" ]; then
				echo "Cannot find eloy.cf"
				exit $E_NOFILE
			fi
		fi
	else
		if [ -f "$1" ]; then
			CONFIG_FILE=$1
		else
			echo "\"$1\" does not exist."
			exit $E_NOFILE
		fi
	fi
}

# searches for the configuration file and includes it.
readConfig() {
	findConfigFile $1
	# Read configuration:
	. "$CONFIG_FILE"
	# Check configuration:
	if [ -z "$SOURCE_DIR" ]; then
		missingConfigVariable "SOURCE_DIR"
	fi
	for DIR in $SOURCE_DIR
	do
	if [ ! -d "$DIR" ]; then
		directoryDoesNotExist "$DIR"
	fi
	done
	if [ -z "$TARGET_DIR" ]; then
		missingConfigVariable "TARGET_DIR"
	fi
	if [ ! -d "$TARGET_DIR" ]; then
		directoryDoesNotExist "$TARGET_DIR"
	fi
	if [ -z "$COMPRESS_KEEP_BACKUPS" ]; then
		missingConfigVariable "COMPRESS_KEEP_BACKUPS"
	fi
	if [ -z "$LOG_FILENAME" ]; then
		missingConfigVariable "LOG_FILENAME"
	fi
}

# prints out information about the Eloy configuration and state.
performInfo() {
	version
	echo
	findConfigFile $1
	echo "Config file: \"$CONFIG_FILE\""
	readConfig $1
	echo "Backup target: $TARGET_DIR"
	echo "Backup source: $SOURCE_DIR"
	if [ -z "$EXCLUDES" ]; then
		echo "Excludes: no excludes defined"
	else
		echo "Excludes: $EXCLUDES"
	fi
	echo "Keep backups: $COMPRESS_KEEP_BACKUPS"
	echo "Logfile: $LOG_FILENAME"
	echo
}

# performs the actual backup operation.
performBackup() {
	TIME_ALL=$(date +%s)
	out ""
	out "Eloy backup - $(date)"
	CURRENT="$TARGET_DIR/current"
	BACKUP_DIR="$TARGET_DIR/$(date +%y-%m-%d-%H%M%S)"
	if [ -d "$CURRENT" ]; then
		out "Creating PARTIAL backup in \"$BACKUP_DIR\"."
	else
		out "Creating INITIAL backup in \"$BACKUP_DIR\"."
	fi
	out $LINE
	for SOURCE in $SOURCE_DIR
	do
		out "$SOURCE"
		ARGS=""
		if [ ! -z "$EXCLUDES" ]; then
			for EXCLUDE in $EXCLUDES
			do
				if [[ "$EXCLUDE" == "$SOURCE"* ]]; then
					ARGS="$ARGS --exclude=/`basename $SOURCE`${EXCLUDE:${#SOURCE}}"
				fi
			done
		fi
		BACKUP_PATH="$BACKUP_DIR.inProgress$SOURCE/.."
		mkdir -p $BACKUP_PATH
		if [ -d "$CURRENT" ]; then
			# Partial backup:
			CURRENT_PATH="$CURRENT$SOURCE/.."
			if [ "$WRITE_LOG" == "yes" ]; then
				rsync -a $ARGS --link-dest=$CURRENT_PATH $SOURCE $BACKUP_PATH >> "$LOG_FILENAME"
			else
				rsync -a $ARGS --link-dest=$CURRENT_PATH $SOURCE $BACKUP_PATH
			fi
		else
			# Initial backup:
			if [ "$WRITE_LOG" == "yes" ]; then
				rsync -a $ARGS $SOURCE $BACKUP_PATH >> "$LOG_FILENAME"
			else
				rsync -a $ARGS $SOURCE $BACKUP_PATH
			fi
		fi
		if [ $? -ne 0 ]; then
			out $LINE
			out "Backup FAILED"
			out $LINE
			rm -rf "$BACKUP_DIR.inProgress"
			out ""
			exit 1
		fi
	done
	# Remove "current" soft link if available:
	if [ -e "$CURRENT" ]; then
		rm $CURRENT
	fi
	# Rename inProgress directory to final name:
	mv "$BACKUP_DIR.inProgress" "$BACKUP_DIR"
	# Create new "current" soft link:
	ln -s $BACKUP_DIR $CURRENT
	TIME_ALL=`expr $(date +%s) - $TIME_ALL`
	out $LINE
	out "Backup finished: $TIME_ALL seconds"
	out $LINE
	out ""
}

findInArray() {
	for X in $1
	do
		if [ "$X" == "$2" ]; then
			return 1
		fi
	done
	return 0
}

# ensures, that the current directory is part of the backup sources.
validateCurrentDir() {
	CURRENT_DIR=`pwd`
	for SOURCE in $SOURCE_DIR
	do
		if [[ "$CURRENT_DIR" == "$SOURCE"* ]]; then
			FOUND=1
			break
		fi
	done
	if [ ! $FOUND ]; then
		echo "
   \"$CURRENT_DIR\" is not in the backup sources.
"
		exit 1
	fi
}

# requires COUNT, FILENAME and TARGET_DIR
performList() {
	validateCurrentDir
	BACKUP_DIRS=`cd "$TARGET_DIR"; ls -r`
	BACKUP_COUNT=0
	BACKUPS=0
	LATEST_BACKUP=""
	HASH=( )
	DIRS=( )
	for BACKUP_DIR in $BACKUP_DIRS
	do
		if [ "$BACKUP_DIR" == "current" ]; then
			continue
		fi
		CURRENT_BACKUP_DIR="$TARGET_DIR/$BACKUP_DIR$CURRENT_DIR"
		if [ ! -d "$CURRENT_BACKUP_DIR" ]; then
			continue
		fi
		if [ "$LATEST_BACKUP" == "" ]; then
			LATEST_BACKUP=$BACKUP_DIR
		fi
		let "BACKUPS+=1"
		FILE_COUNT=0
		FILES=$(cd "$CURRENT_BACKUP_DIR"; ls -A1 | grep "$FILENAME")
		FILES=${FILES// /\/} # escape spaces with slashes
		for FILE in $FILES
		do
			FILE=${FILE//\// } # undo escaping
			if [ -d "$CURRENT_BACKUP_DIR/$FILE" ]; then
				if [ -d "$CURRENT_DIR/$FILE" ]; then
					continue
				fi
				findInArray $DIRS $FILE
				if [ $? -eq 1 ]; then
					continue
				fi
				DIRS[${#DIRS[*]}]=$FILE
			else
				cmp "$CURRENT_BACKUP_DIR/$FILE" "$CURRENT_DIR/$FILE" &> /dev/null
				if [ $? -eq 0 ]; then
					continue
				fi
				INDEX=`ls -i "$CURRENT_BACKUP_DIR/$FILE" | awk '{ print $1 }'`
				if [ "${HASH[$INDEX]}" == "$FILE" ]; then
					continue
				fi
				HASH[$INDEX]=$FILE
			fi
			if [ $FILE_COUNT -eq 0 ]; then
				echo
				echo $LINE
				echo "$BACKUP_DIR"
				echo $LINE
			fi
			INFO=`cd $CURRENT_BACKUP_DIR; ls -Alh | grep -E " ([0-9]{2}:[0-9]{2}|[0-9]{4}) $FILE( -> .*)?$"`
			echo "   ${INFO}"
			let "FILE_COUNT+=1"
		done
		if [ $FILE_COUNT -ne 0 ]; then
			let "BACKUP_COUNT+=1"
			if [ $BACKUP_COUNT -eq $COUNT ]; then
				echo
				return
			fi
		fi
	done
	if [ $BACKUP_COUNT -eq 0 ]; then
		echo
		if [ $BACKUPS -eq 0 ]; then
			echo "   No backups available for \"$CURRENT_DIR\"."
		else
			echo "   Latest backup: $LATEST_BACKUP"
			echo "   No changes found."
		fi
	fi
	echo
}

lookupBackup() {
	if [ -z $LOOKUP_BACKUP ]; then
		BACKUP_DIRS=`cd "$TARGET_DIR"; ls -r`
		for BACKUP_DIR in $BACKUP_DIRS
		do
			if [ "$BACKUP_DIR" == "current" ]; then
				continue
			fi
			CURRENT_BACKUP_DIR="$TARGET_DIR/$BACKUP_DIR$CURRENT_DIR"
			if [ ! -d "$CURRENT_BACKUP_DIR" ]; then
				continue
			fi
			FILE="$CURRENT_BACKUP_DIR/$LOOKUP_FILE"
			if [ ! -e "$FILE" ]; then
				continue
			fi
			if [ -d "$FILE" ]; then
				LOOKUP_BACKUP=$BACKUP_DIR
				break
			else
				cmp "$FILE" "$CURRENT_DIR/$LOOKUP_FILE" &> /dev/null
				if [ $? -ne 0 ]; then
					LOOKUP_BACKUP=$BACKUP_DIR
					break
				fi
			fi
		done
		if [ -z $LOOKUP_BACKUP ]; then
			echo "   No backup found for \"$LOOKUP_FILE\"."
			echo
			exit 1
		fi
	else
		if [ ! -d "$TARGET_DIR/$LOOKUP_BACKUP" ]; then
			echo "   Backup \"$LOOKUP_BACKUP\" does not exist."
			echo
			exit 1
		fi
	fi
}

performRestore() {
	validateCurrentDir
	echo
	lookupBackup
	echo "Restoring \"$LOOKUP_FILE\" from backup \"$LOOKUP_BACKUP\""
	echo $LINE
	BACKUP_DIR="$TARGET_DIR/$LOOKUP_BACKUP"
	FILE="$BACKUP_DIR$CURRENT_DIR/$LOOKUP_FILE"
	if [ -d "$FILE" ]; then
		cp -r -v "$FILE" "$CURRENT_DIR"
	else
		cp -v "$FILE" "$CURRENT_DIR"
	fi
	echo
	exit 0
}

performDiff() {
	validateCurrentDir
	echo
	if [ ! -e "$CURRENT_DIR/$LOOKUP_FILE" ]; then
		echo "   \"$LOOKUP_FILE\" does not exist."
		echo
		exit 1
	fi
	lookupBackup
	echo "Comparing \"$LOOKUP_FILE\" with backup \"$LOOKUP_BACKUP\""
	echo $LINE
	FILE="$TARGET_DIR/$LOOKUP_BACKUP$CURRENT_DIR/$LOOKUP_FILE"
	if [ -d "$FILE" ]; then
		echo
		echo "   \"$LOOKUP_FILE\" is a directory."
		echo
	else
		 diff "$CURRENT_DIR/$LOOKUP_FILE" "$FILE"
	fi
	echo
	exit 0
}

performCompress() {
	out ""
	out "Compressing backups in $TARGET_DIR"
	out $LINE
	DID_SOMETHING=0
	COUNT=0
	BACKUP_DIRS=`cd "$TARGET_DIR"; ls`
	CURRENT_BACKUP_DATE=""
	TODAY_BACKUP_DATE=$(date +%y-%m-%d)
	LS_OUT=$(ls -l "$TARGET_DIR/current")
	CURRENT_BACKUP_DIR=${LS_OUT#*-> }
	for BACKUP_DIR in $BACKUP_DIRS
	do
		# Ignore the symlink to the current backup:
		if [ "$BACKUP_DIR" == "current" ]; then
			continue
		fi
		# Ignore everything that is not a directory:
		if [ ! -d "$TARGET_DIR/$BACKUP_DIR" ]; then
			continue
		fi
		# Make sure the current backup is not touched:
		if [ "$TARGET_DIR/$BACKUP_DIR" == "$CURRENT_BACKUP_DIR" ]; then
			continue
		fi
		# Check whether the backup was created today:
		BACKUP_DATE=${BACKUP_DIR:0:8}
		if [ "$BACKUP_DATE" == "$TODAY_BACKUP_DATE" ]; then
			continue
		fi
		if [ "$BACKUP_DATE" == "$CURRENT_BACKUP_DATE" ]; then
			out "   Removing $BACKUP_DIR"
			DID_SOMETHING=1
			rm -rf "$TARGET_DIR/$BACKUP_DIR"
			continue
		fi
		let "COUNT+=1"
		CURRENT_BACKUP_DATE=$BACKUP_DATE
	done
	if [ $COMPRESS_KEEP_BACKUPS -ne 0 -a $COUNT -gt $COMPRESS_KEEP_BACKUPS ]; then
		BACKUP_DIRS=`cd "$TARGET_DIR"; ls`
		for BACKUP_DIR in $BACKUP_DIRS
		do
			# Ignore the symlink to the current backup:
			if [ "$BACKUP_DIR" == "current" ]; then
				continue
			fi
			# Ignore everything that is not a directory:
			if [ ! -d "$TARGET_DIR/$BACKUP_DIR" ]; then
				continue
			fi
			# Make sure the current backup is not touched:
			if [ "$TARGET_DIR/$BACKUP_DIR" == "$CURRENT_BACKUP_DIR" ]; then
				continue
			fi
			# Check whether the backup was created today:
			BACKUP_DATE=${BACKUP_DIR:0:8}
			if [ "$BACKUP_DATE" == "$TODAY_BACKUP_DATE" ]; then
				continue
			fi
			out "   Removing $BACKUP_DIR"
			DID_SOMETHING=1
			rm -rf "$TARGET_DIR/$BACKUP_DIR"
			let "COUNT-=1"
			if [ $COUNT -eq $COMPRESS_KEEP_BACKUPS ]; then
				break
			fi
		done
	fi
	if [ $DID_SOMETHING == 0 ]; then
		out "   Nothing to do."
	fi
	out ""
	if [ ! -z "$MAILTO" ]; then
		cat "$LOG_FILENAME" | mail -s "Eloy backup log" $MAILTO
		if [ $? -ne 0 ]; then
			out "Error emailing logfile \"$LOG_FILENAME\" to $MAILTO."
		else
			mv "$LOG_FILENAME" "$LOG_FILENAME.$(date +%y%m%d%H%M%S)"
		fi
	fi
	exit 0
}

# Check arguments:
if [ -z $1 ]; then
	NAME=`basename $0`
	echo "Type '$NAME help' for usage."
	exit $E_NO_ARGS
fi
case "$1" in
	"help" | "h" | "?" )
		if [ -z $2 ]; then
			usage
		else
			case "$2" in
				"info" | "i" )
					usageInfo
				;;
				"backup" )
					usageBackup
				;;
				"list" | "l" )
					usageList
				;;
				"restore" | "r" )
					usageRestore
				;;
				"diff" | "d" )
					usageDiff
				;;
				"compress" )
					usageCompress
				;;
				* )
					usage
				;;
			esac
		fi
	;;
	"info" | "i" ) 
		performInfo $2
	;;
	"backup" )
		WRITE_LOG="no"
		while [ $# -ge 2 ]; do
			case "$2" in
				"-l" )
					WRITE_LOG="yes"
					shift
				;;
				* )
					break
				;;
			esac
		done
		readConfig $2
		performBackup
	;;
	"list" | "l" )
		COUNT=$DEFAULT_LIST_COUNT
		FILENAME=""
		while [ $# -ge 2 ]; do
			case "$2" in
				"-c" )
					COUNT=$3
					shift 2
				;;
				"-f" )
					FILENAME=$3
					shift 2
				;;
				* )
					break
				;;
			esac
		done
		readConfig $2
		performList
	;;
	"diff" | "d" )
		while [ $# -ge 2 ]; do
			case "$2" in
				"-b" )
					LOOKUP_BACKUP=$3
					shift 2
				;;
				* )
					if [ -z $LOOKUP_FILE ]; then
						LOOKUP_FILE=$2
						shift
					else
						break
					fi
				;;
			esac
		done
		if [ -z $LOOKUP_FILE ]; then
			usageDiff
			exit 1
		fi
		readConfig $2
		performDiff
	;;
	"restore" | "r" )
		while [ $# -ge 2 ]; do
			case "$2" in
				"-b" )
					LOOKUP_BACKUP=$3
					shift 2
				;;
				* )
					if [ -z $LOOKUP_FILE ]; then
						LOOKUP_FILE=$2
						shift
					else
						break
					fi
				;;
			esac
		done
		if [ -z $LOOKUP_FILE ]; then
			usageRestore
			exit 1
		fi
		readConfig $2
		performRestore
	;;
	"compress" )
		WRITE_LOG="no"
		while [ $# -ge 2 ]; do
			case "$2" in
				"-l" )
					WRITE_LOG="yes"
					shift
				;;
				"-m" )
					MAILTO=$3
					shift 2
				;;
				* )
					break
				;;
			esac
		done
		readConfig $2
		performCompress
	;;
	* )
		usage
		exit 1
	;;
esac

exit 0
